Skip to content

feat(access): the ADR5 grant/consent/visibility layer - #900

Open
jirhiker wants to merge 13 commits into
stagingfrom
feat/scope-based-access-control
Open

feat(access): the ADR5 grant/consent/visibility layer#900
jirhiker wants to merge 13 commits into
stagingfrom
feat/scope-based-access-control

Conversation

@jirhiker

Copy link
Copy Markdown
Member

Storage and evaluator half of ADR5 -- two grant tables, one visibility
layer, and the first (only) tenant that consults it.

What lands

  • ADR5.md -- decides the shape: internal permission vs landowner
    publication consent, one evaluator, one field-projection chokepoint
    (field projection itself is not in this PR).
  • db/permission_grant.py, db/publication_consent.py,
    db/destination.py, db/authorization_audit.py
    -- the storage. No
    wildcards: a grant names its data_type, and nothing sweeps expired
    rows -- every check compares against the date asked about.
  • services/visibility.py -- the only evaluator. may() answers
    internal authorization; published_things() answers "what does this
    destination get." Loads rows, calls domain/access.py (no DB, no
    HTTP) for the rules.
  • api/access.py (/access) -- grants, destinations, consent, and
    a /access/decision introspection route. Every write is logged to
    authorization_audit in the same transaction.
  • services/access_seed.py / oco seed-access-grants -- seeds one
    global grant per (Authentik role, capability, data type) so today's
    roles keep today's access on a fresh environment. Previews by
    default, --apply to write, idempotent, and it will not resurrect a
    grant somebody revoked.
  • b07d968b -- renamed PermissionHistory to
    FieldAccessConsent (table field_access_consent): a landowner's
    consent to physical site access, which is not authorization, and
    was easy to confuse with the new tables this PR adds.
  • Two follow-on commits: widened GET /access/grant to list every
    grant (principal_id now optional, plus capability/data_type/scope_type
    filters) instead of requiring one principal; and a dev-only
    entrypoint.sh fix so a fresh docker-compose DB seeds the new
    lexicon categories automatically instead of 500ing on a FK violation.

What does not land here

No existing endpoint consults this layer yet -- release_status still
governs what the OGC views publish. field-allowlists.yml and
services/field_projection.py (the actual field-by-field redaction)
are a separate PR; a2d9ef69 only moves the public OGC collections
onto the projection groundwork, not new redaction behavior.

Verification

  • uv run pytest tests/test_access.py tests/test_domain_access.py tests/test_access_seed.py -- all green.
  • Manually exercised /access/grant, /access/decision,
    /access/destination, /access/consent against a fresh dev DB
    (docker compose, lexicon seeded via oco initialize-lexicon).

🤖 Generated with Claude Code

jirhiker and others added 11 commits August 25, 2026 17:09
Answers PUB-D14 / PERM-D3 from the "Who May See What, and Who May Do What"
whitepaper: two grant tables, one enforcement engine.

Landowner publication consent and internal permission grants are stored
separately, because they are decided by different people on different
authority and revoked by different events. Both are evaluated by a single
visibility layer and projected through a single field-allowlist chokepoint,
because distributed enforcement has already drifted here: baba91fe5e83 fixed
ogc_waterlevels publishing readings from draft and private wells -- with the
well's name and coordinates attached -- while ogc_water_chemistry had gated
on the parent thing since d9e0f1a2b3c4. Twelve views carry their own copy of
the rule today.

Leaves open, on purpose: data type granularity, whether field protection is
removal or also transformation, where coarse group membership lives, what
each audit event records, and who owns the never-public field list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two vocabulary fixes from ADR5. Neither changes an API response shape.

The table named permission_history records a landowner's consent to physical
site access -- sampling, equipment installation -- which is a domain fact, not
authorization. It is now field_access_consent (model FieldAccessConsent). The
permission_type and permission_allowed columns keep their names, since
permission_type is lexicon-backed and both are published on Thing responses
under the unchanged `permissions` key.

db/permission.py's Permission and base.py's PermissionMixin are deleted. They
were dead: never imported in db/__init__.py, no `permission` table in any
migration, no callers.

release_status was one column carrying two axes -- its lexicon lists draft,
public, private, published and archived next to provisional and final -- so a
record could not be public and provisional at once, which San Acacia data is.
ReleaseMixin now carries data_maturity alongside it. That is not a new
vocabulary: the lexicon category (provisional, in review, approved) and the
column already existed on transducer_observation, whose duplicate declaration
is removed. Nullable, NULL meaning not stated.

No data moves. Rows already marked release_status='provisional' are left
alone; reassigning them across the two axes needs a decision per row and is
tracked in ADR5.

Migration e7c1a9f4b2d8 renames the table (with its primary key constraint and
sequence, so the old name stops appearing in errors) and adds data_maturity to
the 34 other ReleaseMixin tables and the 7 continuum version tables. Verified
down and back up against ocotilloapi_test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The storage and evaluation half of ADR5. No existing endpoint changes
behavior: the layer goes behind one service before it goes behind all of them.

Four tables. permission_grant holds internal authorization -- principal x
capability x scope, one row per data type. publication_consent holds what a
landowner agreed to publish about their well, per destination and data type,
so "levels yes, chemistry no" is a row rather than institutional memory.
destination is the registry those consent rows point at. authorization_audit
is append-only and is written in the same transaction as every change, because
the first question after an exposure is who granted that, and when.

Two tables rather than one because consent and clearance are decided by
different people on different authority and revoked by different events. One
evaluator rather than two because migration baba91fe5e83 already showed what
independent filtering costs.

domain/access.py holds the rules as plain functions over plain values, so they
are testable without a database and cannot be restated differently by a second
caller. Three invariants live there: default deny, no wildcard data type, and
expiry compared at use rather than swept by a job that can be missed. An
unrecognized scope type denies instead of raising, so a row written by a newer
version of the code never reads as permission to an older one.

services/visibility.py is the only place that answers "may this principal" and
"what does this destination get". api/access.py is its one tenant: grant and
consent administration, the destination registry, and a /access/decision
introspection route. The prefix is /access because /publication is already the
bibliography. Writes are Admin, reads are Viewer.

The five vocabularies are lexicon categories seeded from core/lexicon.json,
not enums in code, so adding a destination kind is not a deploy. Data types
are deliberately coarse -- water level, water chemistry, well construction,
site metadata -- and there is deliberately no term meaning "all".

Not included: no pre-existing endpoint consults the layer, so the OGC views
still publish on release_status; field projection is the next step; grants are
read per request with no cache, since an unbounded one would defeat the
immediate-revocation promise; and Authentik roles have no broad day-one grants
yet, so /access/decision currently says no to everyone.

52 tests: 32 with no database over the rules, 20 through the routes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The attribute-level half of ADR5. Fields are published by allowlist, per
audience, at one chokepoint, because the Bureau's promises are field-shaped:
the owner's name and phone number sit on the same record as the water levels
the owner agreed to share.

core/field-allowlists.yml holds the allowlists, keyed by destination kind with
a per-slug override that replaces rather than extends. An audience with no
entry receives an empty record, not the whole one, so a column added next year
is invisible until someone lists it deliberately.

domain/field_projection.py holds the rules over plain dicts.
services/field_projection.py parses the configuration, validates it in full on
first read, and turns a model row into the record an audience receives. It is
called from services/visibility.py, below the routes: api/access.py never
projects anything itself, so a new route or output format cannot skip the rule
by forgetting to call it.

Validation is at load rather than at request time. An unknown field name, a
transform on a field nobody publishes, or an allowlist naming a never-public
field raises immediately, because the alternative is discovering a typo by
noticing data that should have been there -- or data that should not have been.

The never-public list wins over every allowlist and is applied twice, at load
and again at projection. It currently covers provenance columns, legacy AMPAPI
primary keys, and the free-text location and coordinate note columns, which
are where gate codes, lock combinations and candid landowner notes have
landed. Adding to that list is safe at any time; removing from it is a policy
decision with a named owner, and ADR5 records that nobody has been named yet.

Protection includes transformation, not only removal. `round` is the first
transform: the same well reaches a harvester at four decimal places and the
public web at two. Rounded rather than withheld, so protecting a landowner
does not mean dropping the well off the map.

/access/destination/{slug}/thing now returns the projected record and location
alongside the consented data types.

Not covered: the ogc_* views still select their own columns in SQL and gate on
release_status, so nothing on this path applies to them yet; only thing and
location are projectable entities; and field rules between internal roles --
contact information being AMP-only -- are not implemented.

Documented in docs/access-field-projection.md. 22 tests, most needing no
database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An empty grant table denies everyone, which is correct and useless:
/access/decision said no to the people who administer the system. This writes
down the access the Authentik roles already had, so nobody's access changes on
the day the visibility layer starts being consulted (ADR5, 5.2).

68 grants: seven roles x their capabilities x four data types, all globally
scoped. The mapping mirrors core/dependencies.py -- Viewer reads, Editor also
enters and corrects, Admin also administers, the AMP family the same, and
OGCInternal reads because the desktop-GIS mount has never written.

Lexicon* is not seeded: it gates vocabulary, not data. AMP.Staging is not
seeded because it gates a workbench that ships dark, and granting access to
something still being validated is the one outcome nobody intended.

The seeder will not resurrect a grant somebody revoked. Its skip check counts
revoked rows as already-seeded, because narrowing this baseline is the whole
point of writing it down, and a seeder that quietly undid a deliberate
revocation would be worse than no seeder. Every row it writes carries
granted_by = system:day-one-baseline and a reason naming the ADR, so a grant
that exists because of institutional history is distinguishable from one
somebody weighed. Each is audited as grant.created.

Exposed as `oco seed-access-grants`, which previews by default and needs
--apply to write. Grants are security state; creating them as a side effect of
a process start would be the kind of thing nobody remembers is happening. It
has not been run in any environment, so dev, staging and production still deny
everyone until somebody runs it there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four rules on how to approach changes in this repo: state assumptions
before implementing, keep the solution minimal, keep diffs surgical, and
turn tasks into verifiable goals.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The projection chokepoint only covered payloads built by
services/visibility.py; the OGC views selected their own columns in SQL.
This closes that gap for all 27 public collections.

DescribedPostgreSQLProvider now looks its table up in the new ogc: block
of core/field-allowlists.yml and hands the result to pygeoapi as the
provider's `properties`, which is what _select_properties builds the
SELECT from. An unlisted column is never read out of Postgres, so it
cannot surface in a feature, in /schema, or in /queryables, and a filter
cannot probe for it. get_fields is narrowed to match. A collection with
no entry publishes nothing, and tests/test_ogc_projection.py fails on a
missing entry so it shows up in CI rather than in production. The
internal mount (ogc_internal_*) is not projected.

The lists were generated from what each view published on 2026-08-24
minus the never-public fields, so no consumer lost a field it was using.
Two leaks did close: eleven collections were publishing nma_pk_welldata
and ogc_temp_depth_measurements was publishing entered_by, a staff
member's name.

Also fixes PRINCIPAL_API_KEY, which said "api_key" where the lexicon
says "api key". The route validated against the constant, so every
API-key grant was rejected with a 422. A new test pins the domain
vocabularies to the lexicon enums so they cannot drift again.

Drops domain.access.published_thing_ids, which had no callers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Design-canvas artboards, deck sources and their generated PDFs. Working
material, not part of the API, and the PDFs are binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GET /access/grant required principal_id; there was no way to browse
every grant for an admin audit view. principal_id is now an optional
filter, alongside new capability/data_type/scope_type filters -- the
bare route lists everything.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
alembic/versions/79a3ab24627e_add_access_control_tables.py adds
permission_grant etc. but deliberately does not seed the
principal_type/capability/grant_scope_type/access_data_type lexicon
terms those columns FK to -- that's core/lexicon.json via
init_lexicon, kept out of the migration so a new capability is a data
change, not a migration.

entrypoint.sh only ran alembic upgrade head, so a fresh dev DB (e.g.
after dropping the postgres_data_dev volume) has the tables but no
terms, and any POST /access/grant 500s on a FK violation. Run
`oco initialize-lexicon` right after migrations; it's idempotent
(on_conflict_do_nothing), safe on every start. Dev-only --
staging/production deploy runs alembic directly and never calls this
script.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Coverage

80.20% total — gate is 75%.

Coverage for the Python files changed in this PR
Name Stmts Miss Cover Missing
api/access.py 94 9 90% 159, 163-164, 190, 194, 277, 339-340, 358
api/well_inventory.py 0 0 100%
cli/cli.py 693 331 52% 59, 63, 67-71, 85, 253-255, 286, 290, 301-304, 307-309, 343-448, 496-552, 669-670, 677, 691, 707, 716, 721, 726-727, 763-769, 774-776, 783-784, 792, 800, 810-817, 822-823, 828, 830-831, 907-911, 921, 923, 933, 946, 951-952, 955, 986-1072, 1097-1163, 1182-1235, 1261-1273, 1333-1362, 1454-1469, 1473
core/enums.py 63 0 100%
core/feature_provider.py 24 1 96% 67
core/initializers.py 143 20 86% 67-69, 73-82, 184, 188, 206, 277-290, 294-295
db/__init__.py 62 1 98% 87
db/authorization_audit.py 17 1 94% 70
db/base.py 55 2 96% 190, 207
db/contact.py 68 0 100%
db/destination.py 12 1 92% 52
db/field_access_consent.py 24 1 96% 82
db/notes.py 22 1 95% 72
db/permission_grant.py 21 1 95% 92
db/publication_consent.py 22 1 95% 99
db/thing.py 191 5 97% 504, 536, 557, 585-591
db/transducer.py 62 2 97% 130, 133
domain/access.py 103 0 100%
domain/field_projection.py 41 0 100%
schemas/__init__.py 58 9 84% 49-51, 62, 67, 69, 71, 90, 93
schemas/access.py 74 0 100%
schemas/field_access_consent.py 8 0 100%
schemas/thing.py 228 9 96% 287, 298, 323-326, 369, 374-376, 381
services/access_admin.py 58 2 97% 61, 170
services/access_seed.py 41 2 95% 100-101
services/field_projection.py 77 2 97% 105, 158
services/sample_helper.py 31 0 100%
services/scoped_transfer.py 823 464 44% 199, 207, 214, 221-223, 251-254, 288-293, 344-368, 373-386, 411-458, 463-465, 468-483, 486-539, 544-547, 550-608, 611-613, 618-633, 636-678, 681-683, 690-717, 720-724, 760-761, 802-803, 809-832, 837-1003, 1009-1012, 1031, 1055-1056, 1063-1065, 1069, 1078, 1146-1152, 1160-1168, 1178-1182, 1196-1201, 1209-1214, 1222-1228, 1236-1273, 1283, 1310, 1324, 1344, 1348, 1360-1369, 1377-1425, 1449-1456, 1460-1466, 1476-1549, 1559-1586, 1858-1881, 1889-1895, 1917-1924, 1941, 1969-1991
services/util.py 128 43 66% 47-56, 80, 84, 122, 125, 148-149, 187-188, 199-217, 223-238, 271, 290-296
services/visibility.py 72 8 89% 81, 112, 178-182, 214, 253
services/well_inventory_csv.py 307 21 93% 92, 154-157, 260-263, 265-267, 308-310, 344-346, 505, 544, 579, 814-821
TOTAL 3622 937 74%

The two A1 scenarios that need a pre-migration database downgraded to
PRE_A1_REVISION and then seeded through the ORM. Every column added by a
migration newer than that revision is mapped on the models but absent
from the downgraded schema, so the seed insert failed:

    column "data_maturity" of relation "group" does not exist

data_maturity (e7c1a9f4b2d8) is only the current example -- any future
ReleaseMixin column would break these steps the same way.

Seed at head first, then downgrade. The downgrade drops the newer
columns but keeps the rows, which is the pre-migration state these
scenarios are asserting against. This is the order the already-passing
reversibility scenario uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Coverage

80.24% total — gate is 75%.

Coverage for the Python files changed in this PR
Name Stmts Miss Cover Missing
api/access.py 94 9 90% 159, 163-164, 190, 194, 277, 339-340, 358
api/well_inventory.py 0 0 100%
cli/cli.py 693 331 52% 59, 63, 67-71, 85, 253-255, 286, 290, 301-304, 307-309, 343-448, 496-552, 669-670, 677, 691, 707, 716, 721, 726-727, 763-769, 774-776, 783-784, 792, 800, 810-817, 822-823, 828, 830-831, 907-911, 921, 923, 933, 946, 951-952, 955, 986-1072, 1097-1163, 1182-1235, 1261-1273, 1333-1362, 1454-1469, 1473
core/enums.py 63 0 100%
core/feature_provider.py 24 1 96% 67
core/initializers.py 143 20 86% 67-69, 73-82, 184, 188, 206, 277-290, 294-295
db/__init__.py 62 1 98% 87
db/authorization_audit.py 17 1 94% 70
db/base.py 55 2 96% 190, 207
db/contact.py 68 0 100%
db/destination.py 12 1 92% 52
db/field_access_consent.py 24 1 96% 82
db/notes.py 22 1 95% 72
db/permission_grant.py 21 1 95% 92
db/publication_consent.py 22 1 95% 99
db/thing.py 191 5 97% 504, 536, 557, 585-591
db/transducer.py 62 2 97% 130, 133
domain/access.py 103 0 100%
domain/field_projection.py 41 0 100%
schemas/__init__.py 58 9 84% 49-51, 62, 67, 69, 71, 90, 93
schemas/access.py 74 0 100%
schemas/field_access_consent.py 8 0 100%
schemas/thing.py 228 9 96% 287, 298, 323-326, 369, 374-376, 381
services/access_admin.py 58 2 97% 61, 170
services/access_seed.py 41 2 95% 100-101
services/field_projection.py 77 2 97% 105, 158
services/sample_helper.py 31 0 100%
services/scoped_transfer.py 823 464 44% 199, 207, 214, 221-223, 251-254, 288-293, 344-368, 373-386, 411-458, 463-465, 468-483, 486-539, 544-547, 550-608, 611-613, 618-633, 636-678, 681-683, 690-717, 720-724, 760-761, 802-803, 809-832, 837-1003, 1009-1012, 1031, 1055-1056, 1063-1065, 1069, 1078, 1146-1152, 1160-1168, 1178-1182, 1196-1201, 1209-1214, 1222-1228, 1236-1273, 1283, 1310, 1324, 1344, 1348, 1360-1369, 1377-1425, 1449-1456, 1460-1466, 1476-1549, 1559-1586, 1858-1881, 1889-1895, 1917-1924, 1941, 1969-1991
services/util.py 128 43 66% 47-56, 80, 84, 122, 125, 148-149, 187-188, 199-217, 223-238, 271, 290-296
services/visibility.py 72 8 89% 81, 112, 178-182, 214, 253
services/well_inventory_csv.py 307 21 93% 92, 154-157, 260-263, 265-267, 308-310, 344-346, 505, 544, 579, 814-821
TOTAL 3622 937 74%

An embargo is a record withheld from public release until a date decided in
advance: `release_status = 'embargoed'` plus a `release_at` on every
ReleaseMixin table. When the date arrives, `oco release-embargoed --apply`
flips the level to `public` and the record appears in the public OGC
collections on their next refresh.

`release_at` is intent, not enforcement. Nothing on the read path consults it,
and `domain/release.py` deliberately publishes no "is this visible" predicate:
one that compared dates would disagree with the views for up to a day and
would eventually be used as a read-path filter, which is the distributed
filtering ADR5 exists to prevent (migration baba91fe5e83).

Why a job rather than a date predicate in the views: seven of the public
collections are materialized views refreshed by one pg_cron job at 09:00 UTC,
and `current_date` inside a matview is frozen at refresh time. A predicate
would buy nothing on more than half the public surface while costing a
recreation of every relation carrying it. The refresh already sets the
granularity, so an embargo lifts up to a day late and never early. The job
must run before that refresh. Nothing schedules it yet -- the two options
(an HTTP route reachable by App Engine cron, or a SQL reimplementation under
pg_cron) each need a decision nobody has made. Failure is fail-closed either
way: an embargo that does not lift leaves data private.

Embargo only ever widens visibility. Withdrawing something already published
is an immediate `release_status` change made by a person, for the reason
`domain/access.py` never backdates a revocation.

Enforcement, in the four public relations that read the observation chain
(b4c5d6e7f8a9): `release_status IS DISTINCT FROM 'embargoed'` at every level
-- observation, sample, activity, event -- so an embargoed reading cannot be
reached through a public parent. `IS DISTINCT FROM` rather than `<>` because
release_status is nullable and `NULL <> 'embargoed'` is NULL, which would drop
the row.

Deliberately not `= 'public'`, which is what the other observation-backed
relations use. Matching them would also drop every observation at `draft`,
`provisional` or NULL -- a release-policy change with its own row counts to
check, not an embargo. As written the four relations return byte-identical
results until something is actually embargoed; the migration's downgrade
output is string-equal to f4a5b6c7d8e9's production SQL. Migration
w1x2y3z4a5b6 is the record of what the tidier version costs when the row
states are not what you assumed.

The pair is guarded by a CHECK constraint per table rather than by pydantic
alone: a PATCH body carrying only `release_status` is a fragment, not a row,
and the CLI, the transfers and psql do not go through pydantic at all.

Chemistry cannot be embargoed per record. Those four collections read the
legacy NMA_* mirror tables, which carry no release columns; per-well works,
per-result does not. Options are written up in docs/data-embargo.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Coverage

80.24% total — gate is 75%.

Coverage for the Python files changed in this PR
Name Stmts Miss Cover Missing
api/access.py 94 9 90% 159, 163-164, 190, 194, 277, 339-340, 358
api/well_inventory.py 0 0 100%
cli/cli.py 708 344 51% 59, 63, 67-71, 85, 253-255, 286, 290, 301-304, 307-309, 343-448, 496-552, 669-670, 677, 691, 707, 716, 721, 726-727, 763-769, 774-776, 783-784, 792, 800, 810-817, 822-823, 828, 830-831, 907-911, 921, 923, 933, 946, 951-952, 955, 986-1072, 1097-1163, 1182-1235, 1261-1273, 1333-1362, 1454-1469, 1492-1508, 1512
core/enums.py 63 0 100%
core/feature_provider.py 24 1 96% 67
core/initializers.py 143 20 86% 67-69, 73-82, 184, 188, 206, 277-290, 294-295
db/__init__.py 62 1 98% 87
db/authorization_audit.py 18 1 94% 74
db/base.py 58 2 97% 206, 223
db/contact.py 68 0 100%
db/destination.py 12 1 92% 52
db/field_access_consent.py 24 1 96% 82
db/notes.py 22 1 95% 72
db/permission_grant.py 21 1 95% 92
db/publication_consent.py 22 1 95% 99
db/thing.py 191 5 97% 504, 536, 557, 585-591
db/transducer.py 62 2 97% 130, 133
domain/access.py 103 0 100%
domain/field_projection.py 41 0 100%
domain/release.py 20 0 100%
schemas/__init__.py 69 9 87% 54-56, 94, 99, 101, 103, 122, 125
schemas/access.py 74 0 100%
schemas/field_access_consent.py 8 0 100%
schemas/thing.py 228 9 96% 287, 298, 323-326, 369, 374-376, 381
services/access_admin.py 58 2 97% 61, 170
services/access_seed.py 41 2 95% 100-101
services/field_projection.py 77 2 97% 105, 158
services/release_schedule.py 42 6 86% 90-91, 94-97
services/sample_helper.py 31 0 100%
services/scoped_transfer.py 823 464 44% 199, 207, 214, 221-223, 251-254, 288-293, 344-368, 373-386, 411-458, 463-465, 468-483, 486-539, 544-547, 550-608, 611-613, 618-633, 636-678, 681-683, 690-717, 720-724, 760-761, 802-803, 809-832, 837-1003, 1009-1012, 1031, 1055-1056, 1063-1065, 1069, 1078, 1146-1152, 1160-1168, 1178-1182, 1196-1201, 1209-1214, 1222-1228, 1236-1273, 1283, 1310, 1324, 1344, 1348, 1360-1369, 1377-1425, 1449-1456, 1460-1466, 1476-1549, 1559-1586, 1858-1881, 1889-1895, 1917-1924, 1941, 1969-1991
services/util.py 128 43 66% 47-56, 80, 84, 122, 125, 148-149, 187-188, 199-217, 223-238, 271, 290-296
services/visibility.py 72 8 89% 81, 112, 178-182, 214, 253
services/well_inventory_csv.py 307 21 93% 92, 154-157, 260-263, 265-267, 308-310, 344-346, 505, 544, 579, 814-821
TOTAL 3714 956 74%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant